44 — 程序化地形 Shader

Three.js Journey · Bruno Simon · 难度:简单

44 — 程序化地形 Shader 课程海报

介绍

这节课,我们要用 shader 把一个平面变成一个程序化地形,包含山脉、水、沙滩、高原等等:

lesson demo video preview

我们要用到的技术大部分都已经学过了,这是一个边练习、边发现更多小技巧的机会。

设置

起始代码已经包含以下内容:

底板

我们先不直接开始做地形,而是先创建它周围的底板。这个底板需要长这样:

虽然看起来很简单,但在一个平面几何体里挖一个洞其实相当困难,有很多方法可以做,但没有简单的方法。

我们可以用只有 4 个分段的 LatheGeometry。

我们可以用 ExtrudeGeometry。

我们可以创建 4 个按特定方式排列的 BoxGeometry:

我们甚至可以在 Blender 这样的 3D 软件里创建模型再导入。

所有这些方案应该都能给你正确的结果,但还有另一个我想让你发现的方案,那就是 three-bvh-csg。

CSG 代表 Constructive Solid Geometry(构造实体几何),BVH 代表 Bounding Volume Hierarchy(包围体层次结构)。虽然非常有趣,但我们不会解释这两个话题。

一言以蔽之,three-bvh-csg 支持几何体之间的布尔运算。举个例子,你可以用一个球体几何体在盒子几何体上戳一个洞。

在我们的情况里,我们要从一个盒子几何体开始,然后创建一个稍小、但在上下都凸出的盒子几何体。接着我们要让后一个在前一个上戳一个洞:

依赖

three-bvh-csg 在 NPM 上可用:https://www.npmjs.com/package/three-bvh-csg

用 npm install 安装这个库,并强制版本为 0.0.16:

npm install three-bvh-csg@0.0.16

我们强制版本是为了确保它能和这节课一起正常工作。

Brush

运算中用到的每个基础形状都称为一个 Brush。

从 'three-bvh-csg' 导入 Brush:

import { Brush } from 'three-bvh-csg'

在 Placeholder 部分之后,添加一个 Board 部分:

/**
 * Placeholder
 */
// ...

/**
 * Board
 */

我们需要两个 Brush,一个用于基础形状,一个用来在它上面戳洞。

要实例化一个 Brush,我们需要给它发一个 Three.js 几何体。

我们先从基础形状开始,把它命名为 boardFill,用一个 BoxGeometry 实例化一个 Brush:

// Brushes
const boardFill = new Brush(new THREE.BoxGeometry(11, 2, 11))

我们看不到它,但这个形状是存在的。

接下来,让我们创建会戳洞的 Brush。同样用一个 BoxGeometry 实例化一个新的 Brush,但改变它的大小,让它更紧、但更高。最后,把它命名为 boardHole:

// Brushes
const boardFill = new Brush(new THREE.BoxGeometry(11, 2, 11))
const boardHole = new Brush(new THREE.BoxGeometry(10, 2.1, 10))

求值

我们有了两个 brush;现在,我们想用 boardHole 在 boardFill 上戳一个洞。

我们需要实例化一个 Evaluator。

从 'three-bvh-csg' 导入 Evaluator:

import { Evaluator, Brush } from 'three-bvh-csg'

在 Brushes 之后实例化它:

// Evaluate
const evaluator = new Evaluator()

在这个例子里,我们只用 Evaluator 执行一次运算,但注意一个 Evaluator 可以用于多次运算。

我们现在可以用 evaluator 的 evaluate() 方法。这个方法需要两个 Brush 作为最前面的两个参数,还需要运算类型作为第三个参数。

既然我们想戳一个洞,就需要用 SUBTRACTION 运算。

从 'three-bvh-csg' 导入 SUBTRACTION:

import { SUBTRACTION, Evaluator, Brush } from 'three-bvh-csg'

在 evaluator 上调用 evaluate(),把 boardFill、boardHole 和 SUBTRACTION 发给它,并保存为 board:

// Evaluate
const evaluator = new Evaluator()
const board = evaluator.evaluate(boardFill, boardHole, SUBTRACTION)

让我们 console.log() 它:

// Evaluate
const evaluator = new Evaluator()
const board = evaluator.evaluate(boardFill, boardHole, SUBTRACTION)
console.log(board)

board 也是一个 Brush,但更重要的是,Brush 似乎继承自 Object3D,意味着我们可以直接把它加到 scene 里:

// Evaluate
const evaluator = new Evaluator()
const board = evaluator.evaluate(boardFill, boardHole, SUBTRACTION)
scene.add(board)

board 就像一个 mesh,目前它上面应用着一个白色的 MeshBasicMaterial。

材质

现在,我们可以把它的材质换成一个好看的 MeshStandardMaterial 就收工,但为了学习,让我们试几件事。

首先,如果你 console.log() board.material 属性,你会看到以下内容:

const board = evaluator.evaluate(boardFill, boardHole, SUBTRACTION)
console.log(board.material)

你看到的,是一个材质数组,这是因为 three-bvh-csg 保留了两个初始 Brush 的材质。但这也意味着 boardFill 和 boardHole 也像带有自己材质的 Mesh。

你可以在 evaluate() 之前调整它们的 material 属性来测试:

const boardFill = new Brush(new THREE.BoxGeometry(11, 2, 11))
const boardHole = new Brush(new THREE.BoxGeometry(10, 2.1, 10))

boardFill.material.color.set('red')
boardHole.material = new THREE.MeshNormalMaterial()

这之所以可行,是 Three.js 借助我们所说的 groups 实现的。顶点信息被分成组,材质会相应地应用。

你可以对几何体 console.log() 一下 board.geometry.groups 来测试:

console.log(board.geometry.groups)

这些都非常有趣,也可能有用,但对我们来说没用,因为我们想在 board 上用一个简单的 MeshStandardMaterial。

因此,通过在 board.geometry 上调用 clearGroups() 来移除这些组:

const board = evaluator.evaluate(boardFill, boardHole, SUBTRACTION)
board.geometry.clearGroups()

并手动给 board.material 属性添加一个 MeshStandardMaterial 实例:

const board = evaluator.evaluate(boardFill, boardHole, SUBTRACTION)
board.geometry.clearGroups()
board.material = new THREE.MeshStandardMaterial({ color: '#ffffff', metalness: 0, roughness: 0.3 })

Brush 变换

既然 Brush 继承自 Object3D,我们就可以用传统的 position、rotation 和 scale 来变换它。

同样,我们不需要这么做,但为了学习,让我们把 boardHole 上移,这样给我们的底板加一个底部。

把 boardHole 的 position.y 属性设为 0.2:

const boardHole = new Brush(new THREE.BoxGeometry(10, 2.1, 10))
boardHole.position.y = 0.2

什么都没发生,因为即使我们更新了 position,Three.js 内部使用的、结合了 position、rotation 和 scale 的变换矩阵还没有被更新,因为 Three.js 只在渲染时才更新它们。

我们不想按原样渲染 boardHole,所以,取而代之,我们要自己更新矩阵。

更新 position 之后,在 boardHole 上调用 updateMatrixWorld() 方法:

boardHole.position.y = 0.2
boardHole.updateMatrixWorld()

它起作用了,但让我们去掉这部分,因为地形会填满内部:

const boardHole = new Brush(new THREE.BoxGeometry(10, 2.1, 10))
// boardHole.position.y = 0.2
// boardHole.updateMatrixWorld()

阴影

最后,让我们在 board 上同时开启投射和接收阴影:

const board = evaluator.evaluate(boardFill, boardHole, SUBTRACTION)
// ...
board.castShadow = true
board.receiveShadow = true
scene.add(board)

地形基础

让我们开始构建那个地形。

首先,移除占位球体,改为创建一个 Terrain 部分:

/**
 * Terrain
 */

几何体

对于几何体,我们要用一个高度细分的 PlaneGeometry:

// Geometry
const geometry = new THREE.PlaneGeometry(10, 10, 500, 500)

与其旋转 Mesh,我们不如旋转几何体本身,这样在计算海拔时就不必考虑模型变换。

在 geometry 上调用 rotateX() 方法,并把 - Math.PI * 0.5 发给它:

const geometry = new THREE.PlaneGeometry(10, 10, 500, 500)
geometry.rotateX(- Math.PI * 0.5)

材质

对于材质,我们想从 MeshStandardMaterial 开始,并用 Custom Shader Material 改进它,后者已经在项目依赖里可用了。

我们要直接实例化 CustomShaderMaterial。

从 'three-custom-shader-material/vanilla' 导入 CustomShaderMaterial:

import CustomShaderMaterial from 'three-custom-shader-material/vanilla'

实例化一个 CustomShaderMaterial,把它保存为 material,并按如下方式设置 Custom Shader Material 和 MeshStandardMaterial:

// Material
const material = new CustomShaderMaterial({
    // CSM
    baseMaterial: THREE.MeshStandardMaterial,
    silent: true,

    // MeshStandardMaterial
    metalness: 0,
    roughness: 0.5,
    color: '#85d534'
})

我们还没有提供任何 shader 或 uniform,但这不会妨碍材质工作。

Mesh

用 geometry 和 material 实例化一个 Mesh,并保存为 terrain:

// Mesh
const terrain = new THREE.Mesh(geometry, material)

把它设为接收和投射阴影,并添加到 scene:

// Mesh
const terrain = new THREE.Mesh(geometry, material)
terrain.receiveShadow = true
terrain.castShadow = true
scene.add(terrain)

Shader

在 src/shaders/ 文件夹里,创建一个 terrain/ 文件夹。

在 src/shaders/terrain/ 文件夹里,创建一个 vertex.glsl 文件,内容如下:

void main()
{
}

仍然在 src/shaders/terrain/ 文件夹里,创建一个 fragment.glsl 文件,内容如下:

void main()
{
    csm_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}

在 script.js 里,把两者分别导入为 terrainVertexShader 和 terrainFragmentShader:

import terrainVertexShader from './shaders/terrain/vertex.glsl'
import terrainFragmentShader from './shaders/terrain/fragment.glsl'

用 vertexShader 和 fragmentShader 属性把它们加到 CustomShaderMaterial:

const material = new CustomShaderMaterial({
    // CSM
    baseMaterial: THREE.MeshStandardMaterial,
    vertexShader: terrainVertexShader,
    fragmentShader: terrainFragmentShader,
    // ...
}

地形是红色的,说明 shader 在工作。

在 fragment.glsl 里,移除 csm_FragColor 那一行:

void main()
{
}

基础海拔

我们现在想让顶点上下移动来创建地形的海拔,这个过程会和我们在 Raging Sea 那节课里做的非常相似,但这一次用的是 Simplex Noise。

getElevation 函数

我们打算把和海拔相关的代码放进一个 getElevation 函数里。

在 vertex.glsl 里,在 main() 之前,创建一个 getElevation 方法,根据一个 vec2 position 返回一个 float elevation:

float getElevation(vec2 position)
{
    float elevation = 0.0;

    return elevation;
}

void main()
{
}

我们用 vec2 参数,是因为海拔只随 x 和 z 轴变化。

在 main() 函数里,调用 getElevation,给它发一个 csm_Position 的 xz swizzle,并保存为 float elevation 变量:

void main()
{
    // Elevation
    float elevation = getElevation(csm_Position.xz);
}

把它加到 csm_Position 的 y 上:

void main()
{
    // Elevation
    float elevation = getElevation(csm_Position.xz);
    csm_Position.y += elevation;
}

Simplex Noise

为了更新 elevation,我们要用一个 Simplex Noise,它已经在 src/shaders/includes/simplexNoise2d.glsl 里可用了。

我们用 2D Simplex Noise,因为它足够了。尽管 Simplex Noise 随维度的扩展相当好,但出于性能考虑,尽量使用最简单的函数。

在 getElevation 函数之前,包含 ../includes/simplexNoise2d.glsl:

#include ../includes/simplexNoise2d.glsl

float getElevation(vec2 position)
{
    // ...
}

在 getElevation 函数里,调用 simplexNoise2d() 并把 position 发给它。把结果加到 elevation:

float getElevation(vec2 position)
{
    float elevation = 0.0;
    elevation += simplexNoise2d(position);

    return elevation;
}

它算是起作用了,但明暗似乎坏了。

修复法线

就像往常从 vertex shader 更新顶点时一样,我们需要修复法线。而要计算法线,我们要像在 Raging Sea 那节课里一样,用经典的邻居技术。

既然我们已经做过多次,就快速处理它。

首先,我们需要找到两个邻居的位置,我们把它们命名为 A(在正 x 轴方向)和 B(在负 z 轴方向)。两者的距离都是 shift:

在 main() 函数开头,创建一个值为 0.01 的 float shift 变量:

void main()
{
    // Neighbours positions
    float shift = 0.01;

    // ...
}

基于 position 创建一个 vec3 positionA 变量,在 x 轴上走 shift 的距离:

void main()
{
    // Neighbours positions
    float shift = 0.01;
    vec3 positionA = position.xyz + vec3(shift, 0.0, 0.0);

    // ...
}

对 positionB 做同样的事,但在负 z 上走 shift 的距离:

void main()
{
    // Neighbours positions
    float shift = 0.01;
    vec3 positionA = position.xyz + vec3(shift, 0.0, 0.0);
    vec3 positionB = position.xyz + vec3(0.0, 0.0, - shift);

    // ...
}

朝负 z 方向走很重要;否则,法线会上下颠倒。

另外注意我们用的是 position 而不是 csm_Position,但两者效果相同。

接下来,我们需要把海拔应用到邻居上:

在我们计算 elevation 以更新 csm_Position.y 的地方,做同样的事,根据各自的 xz 直接更新 positionA.y 和 positionB.y:

void main()
{
    // ...

    // Elevation
    float elevation = getElevation(csm_Position.xz);
    csm_Position.y += elevation;
    positionA.y    += getElevation(positionA.xz);
    positionB.y    += getElevation(positionB.xz);
}

是时候计算法线了:

计算从 csm_Position 到 positionA 以及到 positionB 的向量,把它们归一化,并保存为 vec3 toA 和 vec3 toB:

void main()
{
    // ...
    
    // Compute normal
    vec3 toA = normalize(positionA - csm_Position);
    vec3 toB = normalize(positionB - csm_Position);
}

我们终于可以用叉积来计算最终法线了:

用 cross 函数计算 toA 与 toB 的叉积,并把输出赋给 csm_Normal:

void main()
{
    // ...
    
    // Compute normal
    vec3 toA = normalize(positionA - csm_Position);
    vec3 toB = normalize(positionB - csm_Position);
    csm_Normal = cross(toA, toB);
}

既然我们不再使用原始的 normal attribute 了,我们不仅可以移除几何体 attributes 里的 normal,还可以移除 uv。

在 script.js 里,创建 geometry 之后,用 deleteAttribute() 方法删除 'uv' 和 'normal' attributes:

const geometry = new THREE.PlaneGeometry(10, 10, 500, 500)
geometry.deleteAttribute('uv')
geometry.deleteAttribute('normal')

海拔频率

我们现在可以着手改进海拔了。

首先,我们想降低频率。

目前,我们直接在 getElevation 里创建一个 uPositionFrequency 变量,但之后我们会用 uniforms,这样就能调节它们。

在 getElevation 里,创建一个值为 0.2 的 float uPositionFrequency 变量,并把 position 乘以它:

float getElevation(vec2 position)
{
    float uPositionFrequency = 0.2;
    
    float elevation = 0.0;
    elevation += simplexNoise2d(position * uPositionFrequency);

    return elevation;
}

为了制造变化,我们要在 elevation 上应用更多 simplexNoise2d,并在每个 simplexNoise2d 之间把频率乘以二:

float getElevation(vec2 position)
{
    float uPositionFrequency = 0.2;
    
    float elevation = 0.0;
    elevation += simplexNoise2d(position * uPositionFrequency      );
    elevation += simplexNoise2d(position * uPositionFrequency * 2.0);
    elevation += simplexNoise2d(position * uPositionFrequency * 4.0);

    return elevation;
}

它起作用了,但每个 simplexNoise2d 的强度都一样。我们想让更高频率对海拔的影响更小,但也要确保最终 elevation 永远不会超过 1.0。让 elevation 保持在 1.0 以下,对之后的效果会很有用。

为此,我们要把第一个 simplexNoise2d 除以 2.0,下一个除以 4.0,再下一个除以 8.0:

float getElevation(vec2 position)
{
    float uPositionFrequency = 0.2;
    
    float elevation = 0.0;
    elevation += simplexNoise2d(position * uPositionFrequency      ) / 2.0;
    elevation += simplexNoise2d(position * uPositionFrequency * 2.0) / 4.0;
    elevation += simplexNoise2d(position * uPositionFrequency * 4.0) / 8.0;

    return elevation;
}

像我们在 Raging Sea 那节课里那样在这里创建一个自动循环会很好,但让我们保持简单、继续前进。

高原

在现实生活中,我们往往在接近海平面的地方看到高原。这可能是自然原因,也可能是人为干预,但无论如何,我们想在程序化地形里得到同样的效果。

要做到这一点,我们会在值接近 0 时把它压下去。如果你记得上一课,我们可以用幂:

这就是我们确保 elevation 永远不会超过 1.0 的原因。

在应用 simplexNoise2d() 之后,对 elevation 应用 2.0 的 pow:

float getElevation(vec2 position)
{
    // ...

    elevation = pow(elevation, 2.0);

    return elevation;
}

在这个阶段,一些 Linux 用户可能会遇到一个 bug:地形的一部分不可见。这发生在对负值使用 pow 的时候。别担心,它过一会儿会自行修复。

它起作用了,但我们失去了 elevation 应该低于 0 的裂缝。

原因是 2 次幂意味着我们把同一个数乘以它自己。举个例子 0.5 * 0.5 = 0.25。但在负数的情况下,-0.5 * -0.5 = 0.25。当你把一个负数乘以另一个负数时,这个值就变成正的了。

要修复那个,我们要在应用 pow 之前取出 elevation 的符号,我们可以用 sign 函数来做到:

float getElevation(vec2 position)
{
    // ...

    float elevationSign = sign(elevation);
    elevation = pow(elevation, 2.0);

    return elevation;
}

elevationSign 对于负值会是 -1.0,对于正值会是 1.0。

我们现在可以把 pow 乘以 elevationSign:

float getElevation(vec2 position)
{
    // ...

    float elevationSign = sign(elevation);
    elevation = pow(elevation, 2.0) * elevationSign;

    return elevation;
}

很好,修好了。嗯,其实没有。如果我们把幂设为 3.0,它又会坏掉:

float getElevation(vec2 position)
{
    // ...
    elevation = pow(elevation, 3.0) * elevationSign;
    // ...
}

原因是奇数次幂不需要我们刚刚应用的技巧。举个例子 -0.5 * -0.5 * -0.5 = -0.125。

要修复那个并确保我们总是得到正确的值,我们会在 pow 内部对 elevation 使用 abs 函数,让它始终为正:

float getElevation(vec2 position)
{
    // ...

    float elevationSign = sign(elevation);
    elevation = pow(abs(elevation), 3.0) * elevationSign;

    return elevation;
}

遇到过前面提到的 bug 的 Linux 用户在这里应该能让它正常工作了。

让我们把幂改回 2.0:

float getElevation(vec2 position)
{
    // ...
    elevation = pow(abs(elevation), 2.0) * elevationSign;
    // ...
}

强度

就像我们为频率创建了一个 float uPositionFrequency 变量一样,让我们为强度创建一个 float uStrength 变量,并设为 2.0:

float getElevation(vec2 position)
{
    float uPositionFrequency = 0.2;
    float uStrength = 2.0;

    // ...
}

在 pow 之后把它乘到 elevation 上:

float getElevation(vec2 position)
{
    // ...
    elevation *= uStrength;

    return elevation;
}

之后,我们会把这些变量替换成真正的 uniforms。

扭曲

海拔看起来漂亮又自然,但我们要通过扭曲 position 来加入更多多样性。

基于 position 创建一个 vec2 warpedPosition 变量,并在全部 3 个 simplexNoise2d() 上使用它:

float getElevation(vec2 position)
{
    // ...

    vec2 warpedPosition = position;
    
    float elevation = 0.0;
    elevation += simplexNoise2d(warpedPosition * uPositionFrequency      ) / 2.0;
    elevation += simplexNoise2d(warpedPosition * uPositionFrequency * 2.0) / 4.0;
    elevation += simplexNoise2d(warpedPosition * uPositionFrequency * 4.0) / 8.0;

    // ...
}

创建一个值为 5.0 的 float uWarpFrequency 和一个值为 0.5 的 float uWarpStrength:

float getElevation(vec2 position)
{
    float uPositionFrequency = 0.2;
    float uStrength = 2.0;
    float uWarpFrequency = 5.0;
    float uWarpStrength = 0.5;

    // ...
}

给 warpedPosition 加一个 simplexNoise2d()。参数用 warpedPosition 乘以 uPositionFrequency(这样它跟随位置频率)再乘以 uWarpFrequency:

float getElevation(vec2 position)
{
    // ...

    vec2 warpedPosition = position;
    warpedPosition += simplexNoise2d(warpedPosition * uPositionFrequency * uWarpFrequency);
    
    // ...
}

把整个 simplexNoise2d() 乘以 uWarpStrength:

float getElevation(vec2 position)
{
    // ...

    vec2 warpedPosition = position;
    warpedPosition += simplexNoise2d(warpedPosition * uPositionFrequency * uWarpFrequency) * uWarpStrength;
    
    // ...
}

海拔看起来更异域了,那些波纹几乎看起来像侵蚀。

Uniform

是时候把 uPositionFrequency、uStrength、uWarpFrequency 和 uWarpStrength 变成 uniforms,这样我们就能调节它们了。

如果你记得前几课,我们会遇到一个阴影问题,需要我们提供一个自定义的 MeshDepthMaterial。我们稍后做,但这说明我们需要给两个材质提供 uniforms,这就是为什么我们要把这些 uniforms 创建一次、用在两个材质上。

在 script.js 里,在 material 之前,创建一个 uniforms 对象,并用 Uniform 类按如下方式设置它的属性:

// Material
const uniforms = {
    uPositionFrequency: new THREE.Uniform(0.2),
    uStrength: new THREE.Uniform(2.0),
    uWarpFrequency: new THREE.Uniform(5),
    uWarpStrength: new THREE.Uniform(0.5),
}

const material = new CustomShaderMaterial({
    // ...
})

设置 uniforms 对象之后,立即创建对应的调节项:

const uniforms = {
    // ...
}

gui.add(uniforms.uPositionFrequency, 'value', 0, 1, 0.001).name('uPositionFrequency')
gui.add(uniforms.uStrength, 'value', 0, 10, 0.001).name('uStrength')
gui.add(uniforms.uWarpFrequency, 'value', 0, 10, 0.001).name('uWarpFrequency')
gui.add(uniforms.uWarpStrength, 'value', 0, 1, 0.001).name('uWarpStrength')

把 uniforms 对象发给 CustomShaderMaterial 的 uniforms 属性:

const slicedMaterial = new CustomShaderMaterial({
    // CSM
    baseMaterial: THREE.MeshStandardMaterial,
    vertexShader: terrainVertexShader,
    fragmentShader: terrainFragmentShader,
    uniforms: uniforms,
    silent: true,

    // ...
})

在 vertex.glsl 里,把 4 个 uniforms 取回为 float,并从 getElevation 函数里删掉那些临时变量:

uniform float uPositionFrequency;
uniform float uStrength;
uniform float uWarpFrequency;
uniform float uWarpStrength;

// ...

float getElevation(vec2 position)
{
    vec2 warpedPosition = position;
    warpedPosition += simplexNoise2d(warpedPosition * uPositionFrequency * uWarpFrequency) * uWarpStrength;
    
    // ...
}
lesson demo video preview

平移

为了给整体增添更多生气,我们要让地形持续移动。

要做到这一点,我们要把时间应用到 warpedPosition 上。

在 script.js 里,用 Uniform 类添加 uTime uniform:

const uniforms = {
    uTime: new THREE.Uniform(0),
    // ...
}

在 tick 函数里,用 elapsedTime 变量更新 uniforms.uTime 的 value:

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()

    // Uniforms
    uniforms.uTime.value = elapsedTime

    // ...
}

在 vertex.glsl 里,把 uTime uniform 取回为 float:

uniform float uTime;

在 getElevation 函数里,把它加到 warpedPosition 上:

float getElevation(vec2 position)
{
    vec2 warpedPosition = position;
    warpedPosition += uTime;
    // ...
}
lesson demo video preview

这导致 warpedPosition 的 x 和 y 都受到影响。

通过把 uTime 乘以 0.2 来放慢动画:

float getElevation(vec2 position)
{
    vec2 warpedPosition = position;
    warpedPosition += uTime * 0.2;
    // ...
}
lesson demo video preview

修复阴影

虽然由于地形细节丰富,它不那么明显,但投影是坏的。

就像上一课一样,我们需要提供一个自定义深度材质。

在 script.js 里,复制 material 并把它命名为 depthMaterial:

const material = new CustomShaderMaterial({
    // ...
})

const depthMaterial = new CustomShaderMaterial({
    // ...
})

把 baseMaterial 改成 THREE.MeshDepthMaterial:

const depthMaterial = new CustomShaderMaterial({
    // CSM
    baseMaterial: THREE.MeshDepthMaterial,
    // ...
})

移除和 MeshStandardMaterial 相关的属性,并添加我们唯一需要的、和 MeshDepthMaterial 相关的属性,也就是把 depthPacking 设为 THREE.RGBADepthPacking:

const depthMaterial = new CustomShaderMaterial({
    // CSM
    baseMaterial: THREE.MeshDepthMaterial,
    vertexShader: terrainVertexShader,
    fragmentShader: terrainFragmentShader,
    uniforms: uniforms,
    silent: true,

    // MeshDepthMaterial
    depthPacking: THREE.RGBADepthPacking
})

移除 fragmentShader,因为在这个情况下它不会改变任何东西:

const depthMaterial = new CustomShaderMaterial({
    // CSM
    baseMaterial: THREE.MeshDepthMaterial,
    vertexShader: terrainVertexShader,
    uniforms: uniforms,
    silent: true,

    // MeshDepthMaterial
    depthPacking: THREE.RGBADepthPacking
})

把 depthMaterial 赋给 terrain 的 customDepthMaterial 属性:

const terrain = new THREE.Mesh(geometry, material)
terrain.customDepthMaterial = depthMaterial
lesson demo video preview

颜色

我们要添加各种颜色,并且根据海拔来做:

有些颜色会突变,有些会平滑过渡。

除此之外,我们要添加一个岩石颜色,但只在接近垂直的面上。

首先,在 fragment.glsl 里,创建一个 vec3 color 变量,把它设为白色并发送给 csm_DiffuseColor:

void main()
{
    // Color
    vec3 color = vec3(1.0);

    // Final color
    csm_DiffuseColor = vec4(color, 1.0);
}

我们添加的每种颜色都会更新 color。

Uniform

我们需要 6 种颜色。与其一个一个创建、或者等到最后才添加 uniforms,不如一开始就把它们全部加上:

我们要用惯常的技巧,把颜色作为独立于 Three.js 的属性来创建,给调试面板添加相应的调节项,并把它们的值注入 Color uniforms。

在 script.js 里,就在 uniforms 对象之前,把以下颜色加到 debugObject:

// Material
debugObject.colorWaterDeep = '#002b3d'
debugObject.colorWaterSurface = '#66a8ff'
debugObject.colorSand = '#ffe894'
debugObject.colorGrass = '#85d534'
debugObject.colorSnow = '#ffffff'
debugObject.colorRock = '#bfbd8d'

const uniforms = {
    // ...
}

用 Color 类和 Uniform 类把这些属性注入到对应的 uniforms:

const uniforms = {
    // ...
    uColorWaterDeep: new THREE.Uniform(new THREE.Color(debugObject.colorWaterDeep)),
    uColorWaterSurface: new THREE.Uniform(new THREE.Color(debugObject.colorWaterSurface)),
    uColorSand: new THREE.Uniform(new THREE.Color(debugObject.colorSand)),
    uColorGrass: new THREE.Uniform(new THREE.Color(debugObject.colorGrass)),
    uColorSnow: new THREE.Uniform(new THREE.Color(debugObject.colorSnow)),
    uColorRock: new THREE.Uniform(new THREE.Color(debugObject.colorRock)),
}

用 addColor() 方法添加对应的调节项:

gui.addColor(debugObject, 'colorWaterDeep')
gui.addColor(debugObject, 'colorWaterSurface')
gui.addColor(debugObject, 'colorSand')
gui.addColor(debugObject, 'colorGrass')
gui.addColor(debugObject, 'colorSnow')
gui.addColor(debugObject, 'colorRock')

在每个 addColor() 之后用 onChange() 方法,通过 set() 方法更新对应的 uniform:

gui.addColor(debugObject, 'colorWaterDeep').onChange(() => uniforms.uColorWaterDeep.value.set(debugObject.colorWaterDeep))
gui.addColor(debugObject, 'colorWaterSurface').onChange(() => uniforms.uColorWaterSurface.value.set(debugObject.colorWaterSurface))
gui.addColor(debugObject, 'colorSand').onChange(() => uniforms.uColorSand.value.set(debugObject.colorSand))
gui.addColor(debugObject, 'colorGrass').onChange(() => uniforms.uColorGrass.value.set(debugObject.colorGrass))
gui.addColor(debugObject, 'colorSnow').onChange(() => uniforms.uColorSnow.value.set(debugObject.colorSnow))
gui.addColor(debugObject, 'colorRock').onChange(() => uniforms.uColorRock.value.set(debugObject.colorRock))

在 fragment.glsl 里,把全部 6 个 uniforms 取回为 vec3:

uniform vec3 uColorWaterDeep;
uniform vec3 uColorWaterSurface;
uniform vec3 uColorSand;
uniform vec3 uColorGrass;
uniform vec3 uColorSnow;
uniform vec3 uColorRock;

Position varying

我们想根据海拔使用那些颜色。

我们需要来自 vertex shader 的那个海拔,但与其只发 elevation,我们不如发整个 csm_Position,这很快就会派上用场。

在 vertex.glsl 里,创建一个 vec3 vPosition varying,并把 csm_Position 发给它:

varying vec3 vPosition;

// ...

void main()
{
    // ...

    // Varyings
    vPosition = csm_Position;
}

在 fragment.glsl 里,取回 vec3 vPosition varying:

varying vec3 vPosition;

水

我们要从下往上进行,先从水开始。

我们想在 vPosition.y 从 -1.0 到 -0.1 时,在 uColorWaterDeep 和 uColorWaterSurface 之间混合。

我们要在 vPosition.y 上用一个从 -1.0 到 -0.1 的 smoothstep,并把结果保存为 float surfaceWaterMix:

void main()
{
    // ...

    // Water
    float surfaceWaterMix = smoothstep(- 1.0, - 0.1, vPosition.y);

    // ...
}

接下来,用 mix 函数根据 surfaceWaterMix 在 uColorWaterDeep 和 uColorWaterSurface 之间混合,并把结果赋给 color:

void main()
{
    // ...

    // Water
    float surfaceWaterMix = smoothstep(- 1.0, - 0.1, vPosition.y);
    color = mix(uColorWaterDeep, uColorWaterSurface, surfaceWaterMix);

    // ...
}

欢迎来到水世界。

沙

我们要明确地划分水和沙。

与其使用 if 语句,我们不如用 step 函数。

在水那部分之后,对 vPosition.y 用一个阈值 -0.1 的 step,并把它赋给 float sandMix 变量:

void main()
{
    // ...

    // Water
    // ...

    // Sand
    float sandMix = step(- 0.1, vPosition.y);

    // ...
}

用 mix 函数根据 sandMix 在 color 和 uColorSand 之间混合,并把结果赋给 color:

void main()
{
    // ...

    // Sand
    float sandMix = step(- 0.1, vPosition.y);
    color = mix(color, uColorSand, sandMix);

    // ...
}

草地

让我们走完全相同的流程,但这次是草地。

把变量命名为 grassMix,并把阈值设为 -0.06:

void main()
{
    // ...

    // Sand
    // ...

    // Grass
    float grassMix = step(- 0.06, vPosition.y);

    // ...
}

用 mix 函数根据 grassMix 在 color 和 uColorGrass 之间混合,并把结果赋给 color:

void main()
{
    // ...

    // Grass
    float grassMix = step(- 0.06, vPosition.y);
    color = mix(color, uColorGrass, grassMix);

    // ...
}

雪

再来一次,但这次是雪。

把变量命名为 snowMix,并把阈值设为 0.45:

void main()
{
    // ...

    // Grass
    // ...

    // Snow
    float snowMix = step(0.45, vPosition.y);

    // ...
}

用 mix 函数根据 snowMix 在 color 和 uColorSnow 之间混合,并把结果赋给 color:

void main()
{
    // ...

    // Snow
    float snowMix = step(0.45, vPosition.y);
    color = mix(color, uColorSnow, snowMix);

    // ...
}

看起来还行,但太规律了。

我们要用惯常的 Simplex Noise 函数根据 vPosition.xz 添加变化,并把那个 Simplex Noise 应用到 0.45 阈值上。

在 main() 之前,包含 ../includes/simplexNoise2d.glsl:

#include ../includes/simplexNoise2d.glsl

创建一个 float snowThreshold 变量,赋给它 0.45。然后,把它用在 step 上:

void main()
{
    // ...

    // Snow
    float snowThreshold = 0.45;
    float snowMix = step(snowThreshold, vPosition.y);
    color = mix(color, uColorSnow, snowMix);

    // ...
}

没有区别,但我们现在可以把 Simplex Noise 应用到 snowThreshold 上了。

根据 vPosition.xz 调用 simplexNoise2d(),并把结果加到 snowThreshold:

void main()
{
    // ...

    // Snow
    float snowThreshold = 0.45;
    snowThreshold += simplexNoise2d(vPosition.xz);

    // ...
}
lesson demo video preview

通过把 vPosition.xz 乘以 15.0 来提高频率:

void main()
{
    // ...

    // Snow
    float snowThreshold = 0.45;
    snowThreshold += simplexNoise2d(vPosition.xz * 15.0);
    // ...
}
lesson demo video preview

通过把整个 simplexNoise2d() 乘以 0.1 来降低强度:

void main()
{
    // ...

    // Snow
    float snowThreshold = 0.45;
    snowThreshold += simplexNoise2d(vPosition.xz * 15.0) * 0.1;
    // ...
}
lesson demo video preview

这基本上就是我们要的,但图案没有跟随动画。

这是由我们在 vertex.glsl 和 getElevation 函数里做的事导致的,我们把 uTime 应用到了 warpedPosition。

要修复那个,把同样的 uTime 加到 vPosition.xz:

void main()
{
    // ...

    // Varyings
    vPosition = csm_Position;
    vPosition.xz += uTime * 0.2;
}
lesson demo video preview

岩石

对于岩石,我们想让它在地表几乎垂直时可见,但只在草地高度之上。

我们要用点积(法线的点积)。

与其把整个法线发给 fragment,我们不如在 vertex shader 里计算点积,只把结果发给 fragment shader。

在 vertex.glsl 里,创建一个 float vUpDot varying:

varying float vUpDot;

在 main() 里,对 csm_Normal 和一个正上方的向量使用 dot 函数:

void main()
{
    // ...
    vUpDot = dot(csm_Normal, vec3(0.0, 1.0, 0.0));
}

在 fragment.glsl 里,取回 float vUpDot varying:

varying float vUpDot;

我们不想让岩石遮住雪,这就是为什么我们把它放在雪之前。

创建一个 float rockMix 变量,赋给它 vUpDot:

void main()
{
    // ...

    // Rock
    float rockMix = vUpDot;

    // Snow
    // ...
}

目前,当面完全朝上时 vUpDot 返回 1.0,当面完全朝侧面时返回 0.0:

我们可以在 vUpDot 上用阈值 0.8 的 step:

void main()
{
    // ...

    // Rock
    float rockMix = vUpDot;
    rockMix = step(0.8, rockMix);

    // ...
}

然后用 mix 函数根据 rockMix 在 color 和 uColorRock 之间混合,并把结果赋给 color:

void main()
{
    // ...

    // Rock
    float rockMix = vUpDot;
    rockMix = step(0.8, rockMix);
    color = mix(color, uColorRock, rockMix);

    // ...
}

不幸的是,它和我们要的相反。

我们可以通过用 1.0 减去 step 来轻松修复:

void main()
{
    // ...

    // Rock
    float rockMix = vUpDot;
    rockMix = 1.0 - step(0.8, rockMix);
    color = mix(color, uColorRock, rockMix);

    // ...
}

或者把 step 的两个参数反过来,但让我们坚持用更容易理解的做法。

岩石目前在水里也可见。要修复那个,我们可以把 rockMix 乘以我们给草地用过的同一个 step:

void main()
{
    // ...

    // Rock
    float rockMix = vUpDot;
    rockMix = 1.0 - step(0.8, rockMix);
    rockMix *= step(- 0.06, vPosition.y);
    color = mix(color, uColorRock, rockMix);

    // ...
}

很好!现在颜色部分就完成了。

水面

让我们的程序化世界闪耀的最后一项,是添加一个水面。我们希望那个面对其下方的东西折射,把它模糊掉,并反射环境贴图和光。

我们要用 MeshPhysicalMaterial 和它的 transmission 特性。

在 script.js 里,在 Terrain 部分之后,用 PlaneGeometry 和 MeshPhysicalMaterial 添加一个 water Mesh:

/**
 * Water
 */
const water = new THREE.Mesh(
    new THREE.PlaneGeometry(10, 10, 1, 1),
    new THREE.MeshPhysicalMaterial()
)
scene.add(water)
lesson demo video preview

旋转这个 Mesh 并把它下移到 -0.1,让它和 fragment shader 里水停止的位置匹配:

const water = new THREE.Mesh(
    new THREE.PlaneGeometry(10, 10, 1, 1),
    new THREE.MeshPhysicalMaterial()
)
water.rotation.x = - Math.PI * 0.5
water.position.y = - 0.1
scene.add(water)

把 MeshPhysicalMaterial 的 transmission 属性设为 1,roughness 设为 0.3:

const water = new THREE.Mesh(
    new THREE.PlaneGeometry(10, 10, 1, 1),
    new THREE.MeshPhysicalMaterial({
        transmission: 1,
        roughness: 0.3
    })
)
water.rotation.x = - Math.PI * 0.5
water.position.y = - 0.1
scene.add(water)
lesson demo video preview

更进一步

我们完成了,但还有改进的空间。

这里有一些建议: